Skip to content

fix: decode invalid UTF-8 at the JVM to native FFI import boundary - #5310

Merged
manuzhang merged 3 commits into
apache:mainfrom
manuzhang:codex/ffi-utf8-decode
Sep 18, 2026
Merged

manuzhang merged 3 commits into
apache:mainfrom
manuzhang:codex/ffi-utf8-decode

Conversation

@manuzhang

@manuzhang manuzhang commented Aug 8, 2026

Copy link
Copy Markdown
Member

This PR continues the work from #4945.

Which issue does this PR close?

Part of #4764 (EPIC: consistent handling of invalid UTF-8 in native StringType). This PR implements Gap B only, decoding invalid UTF-8 at the JVM to native Arrow FFI import boundary. It does not close the EPIC, which still tracks Gap A (the native scan rejecting invalid UTF-8).

Rationale for this change

Spark's StringType (UTF8String) can hold arbitrary bytes, including sequences that are not valid UTF-8. When a JVM side source hands string columns to native code over the Arrow C Data Interface, arrow-rs imports them with from_ffi / from_ffi_and_data_type, which build the array via ArrayData::new_unchecked and do not validate UTF-8. The imported Arrow Utf8 / LargeUtf8 array then lies about its validity, and any downstream native string kernel that reads &str through arrow-rs's unchecked StringArray::value() (from_utf8_unchecked) exercises undefined behaviour: iterating chars, slicing on char boundaries, and similar operations can misbehave, panic, or be miscompiled. This is a latent, default configuration soundness hazard.

The string producing sites already decode invalid bytes the way Spark renders them: CAST(binary AS string) (#4763) and native shuffle get_string (#4521) both use decode_utf8_spark_lossy. This PR applies the same policy to the string ingress side, so the whole native pipeline agrees on a single invariant: native string data is always valid UTF-8.

What changes are included in this PR?

  • A new decode_string_arrays walker in datafusion-comet-common, beside the existing decode_utf8_spark_lossy. It ensures every Utf8 / LargeUtf8 array reachable from an imported column holds valid UTF-8, decoding invalid bytes to Spark's rendered form. It is zero copy for the valid common case (one validation pass plus an O(number of strings) boundary check, returning the same Arc), and rebuilds element by element only when bytes are genuinely invalid. It recurses through Dictionary, Struct, List, LargeList, FixedSizeList, and Map.

  • There are four from_ffi call sites. Three carry string data and decode directly:

    • ScanExec::pull_next, which handles all native query input (the JVM reader, Spark columnar handoff, shuffle reads, mapInArrow). Decoding runs before dictionary unpack so compact dictionary values are validated rather than the expanded ones.
    • columnarToRow conversion.
    • JVM UDF result.

    The fourth, batch_from_ffi in aligned_stream_reader.rs, is consumed only by ScanExec, which passes every column through import_column immediately after the batch is materialised. Decoding there as well would validate the same buffers twice, so its doc comment records that any future consumer must keep that step.

  • Anything string-bearing that the walker does not handle fails closed rather than passing unchecked data through. data_type_contains_string is an exhaustive match, so an Arrow upgrade that adds a data type forces this decision to be revisited, and types such as Utf8View, the view lists and RunEndEncoded return NotYetImplemented if they ever reach this boundary.

  • The fast path validates the used byte range and also confirms no element boundary splits a codepoint. This second check is required for soundness: a whole buffer valid "é" (bytes C3 A9) split across two offsets would otherwise hand each element an invalid slice, and value() decodes those unchecked.

  • Validation uses simdutf8 rather than std::str::from_utf8 (see Performance below). simdutf8 is already in the dependency graph through arrow-json, so this adds no new third-party crate.

  • Benchmarks: a criterion benchmark for the valid fast path, and CometStringFfiImportBenchmark, which measures both import sites end to end.

No configuration flag gates this. It fixes undefined behaviour in the default configuration and matches the always on behaviour of the sibling cast and shuffle fixes.

The only observable divergence from Spark is the previously documented one: decoding rather than preserving raw bytes differs only under byte level round trips (for example CAST(CAST(X'FF' AS STRING) AS BINARY)), already noted in the compatibility guide.

Performance

Validation costs one pass over each imported string buffer, so the cost scales with the bytes crossing the boundary, not with the number of rows.

CometStringFfiImportBenchmark (added here) measures both import sites over 1M rows of roughly 180 byte strings, in ASCII and in multibyte text. Each imported case is paired with a control that does the same work without crossing an import boundary, and every case checks its executed plan before it is timed. Best time on an M2:

case before after
ASCII, JVM Parquet scan into native (imported) 108 ms 115 ms (+6%)
ASCII, Comet native Parquet scan (control) 63 ms 64 ms
ASCII, native columnar to row (imported) 56 ms 62 ms (+11%)
ASCII, JVM columnar to row (control) 42 ms 42 ms
multibyte, JVM Parquet scan into native (imported) 109 ms 234 ms (+115%)
multibyte, Comet native Parquet scan (control) 76 ms 77 ms
multibyte, native columnar to row (imported) 59 ms 194 ms (+229%)
multibyte, JVM columnar to row (control) 58 ms 66 ms

The controls stay flat, which is what places the cost in validation rather than anywhere else.

Those numbers come from validating with std::str::from_utf8. It has a fast ASCII path but degrades to a byte at a time loop on non-ASCII text. Timing the validator alone on the same string shapes, 8192 rows per batch:

strings std::str::from_utf8 simdutf8::basic
ASCII, 156 bytes/row 4.8 ns/row (32 GB/s) 1.7 ns/row (92 GB/s)
multibyte, 184 bytes/row 111 ns/row (1.65 GB/s) 15.7 ns/row (11.7 GB/s)

111 ns/row accounts for the whole multibyte regression above, and the boundary check costs under 1 ns/row. The last commit therefore switches the fast path to simdutf8, which is 7x faster on multibyte data and 3x on ASCII. Substituting 15.7 ns/row back into the cases above projects roughly +15% for the multibyte converted scan and +29% for multibyte native columnar to row, with ASCII near noise.

The simdutf8 numbers are measured at the validator, not end to end. Repeated attempts to re-time the full benchmark against the simdutf8 build on this machine were spoiled by background load (the same case varied up to 4x between runs), so the end to end table above is still the std build. A quiet machine or the EC2 micro benchmark runner (benchmarks/micro/run.py) should confirm the projection before this is taken as final.

Skipping validation where the producer is known to be native is not available as a shortcut: native columnar to row can receive strings built on the JVM, for example through a union, the in-memory cache, or broadcast.

How are these changes tested?

  • Rust unit tests for the walker covering: valid input returned zero copy (pointer identical buffers); invalid bytes decoding to U+FFFD matching the JVM; the split codepoint boundary case; nested Dictionary / Struct / List / FixedSizeList / Map (decode and zero copy paths); null preservation; sliced arrays with a non zero starting offset; and trailing empty strings.
  • A Rust test at the ScanExec import site proving an invalid UTF-8 column is decoded through the production per column path.
  • Both suites pass against the simdutf8 fast path.
  • A criterion benchmark for the valid fast path, and CometStringFfiImportBenchmark for the two import sites end to end.

@0lai0 0lai0 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @manuzhang for picking this up. left some comments

Comment thread native/core/src/execution/jni_api.rs Outdated
Comment thread native/common/src/utf8.rs Outdated
@manuzhang
manuzhang requested a review from 0lai0 August 11, 2026 04:11
@andygrove

Copy link
Copy Markdown
Member

Thanks for picking this up @manuzhang. I probably can't review in detail until next week. How does this relate to the work in #5267? Will that PR remain open?

@manuzhang

Copy link
Copy Markdown
Member Author

@andygrove #5267 has been closed in favor of this approach.

@manuzhang
manuzhang force-pushed the codex/ffi-utf8-decode branch from e81cf0d to d751c60 Compare August 22, 2026 15:26
@manuzhang
manuzhang requested review from andygrove and removed request for 0lai0 August 22, 2026 15:28
@andygrove

Copy link
Copy Markdown
Member

Note on this review: this was generated by an LLM (Claude Code) at my request while I worked through a review backlog. I have not verified the individual findings myself. Please treat everything below as suggestions to evaluate rather than as authoritative review feedback, and push back on anything that is wrong or already handled.

This is worth doing. from_ffi building string arrays through new_unchecked and then arrow-rs handing out &str via from_utf8_unchecked really is undefined behavior in the default configuration, and closing it at the import boundary is the right place. The zero-copy Arc::ptr_eq contract through the nested arms is nicely done, and decoding before copy_or_unpack_array so a dictionary decodes its compact values rather than the expanded ones is a good catch.

Three things.

aligned_stream_reader.rs is a fourth import site and is not covered

batch_from_ffi in native/core/src/execution/operators/aligned_stream_reader.rs calls from_ffi_and_data_type, and its own doc comment says the function "returns the producer's buffers untouched (via new_unchecked)". If that stream ever carries a JVM-produced string column, it has exactly the hole this PR exists to close, and it would be the one place left after this merges.

Is that path reachable with string data? If it is, it needs decode_string_arrays too. If it is not, the description should list all four from_ffi sites and say why that one is exempt, so the next person auditing this does not have to rediscover it.

The default arm silently passes through

_ => Ok(Arc::clone(array)),

The comment is honest about the risk and says a view-typed column would silently return the UB. But a comment is not a mechanism. In a function whose entire purpose is to prevent unsoundness, an unknown type should not take the quiet path.

Could the default arm return an error for the types that could plausibly contain strings, specifically Utf8View, ListView, LargeListView, and RunEndEncoded, and keep the silent clone only for types that provably cannot? A hard error on an unexpected string-bearing type turns a latent memory-safety bug into a clear failure, and if Utf8View ever does arrive over FFI, that is much better than what the comment describes.

The performance cost needs an end-to-end number

Every string column crossing the JVM to native boundary now pays a full UTF-8 validation pass per batch. The benchmark measures decode_string_arrays in isolation on an 8192-row valid batch, which tells us the function is fast but not what it costs a query.

Could you add a before-and-after on something string-heavy end to end, say TPC-H Q1 or a filter over a wide string column with spark.comet.scan.impl set to the JVM path? This is a soundness fix so it should land regardless of the number, but users should be told what it costs, and if it turns out to be material there may be a case for validating once per column rather than once per batch, or for trusting a producer that guarantees validity.

One smaller note

scans.md is updated, which is right. Does it now say clearly which import boundaries are covered and that Gap A (the native scan) is still open under #4764? A user reading that page should be able to tell whether their configuration is affected.

@manuzhang
manuzhang force-pushed the codex/ffi-utf8-decode branch 3 times, most recently from ed0d2f2 to cf1e794 Compare August 29, 2026 16:29
@andygrove andygrove added bug Something isn't working correctness area:ffi Arrow FFI / JNI boundary labels Sep 6, 2026
@manuzhang

Copy link
Copy Markdown
Member Author

@andygrove could you please take another look?

@kazuyukitanimura

Copy link
Copy Markdown
Contributor

Hi @manuzhang
Thank you for this PR. Would you mind fixing the conflict please?

andygrove and others added 3 commits September 17, 2026 23:25
Co-authored-by: Manu Zhang <owenzhang1990@gmail.com>

Co-authored-by: Codex <codex@openai.com>
Measures the UTF-8 validation that native code now runs on every string
column imported from the JVM, at the two import sites that carry most
string data: `ScanExec` fed by a JVM operator (Spark's Parquet reader
converted to Arrow) and native columnar-to-row conversion. Each imported
case is paired with a control that does the same work without an import,
and every Comet case checks its executed plan before it is timed. Both
ASCII and multibyte wide strings are covered, since validation takes a
slower path on non-ASCII bytes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mypwig1QwDcPvDMadVB1d
`decode_string_arrays` validated every imported string column with
`std::str::from_utf8`. That has a fast ASCII path but falls back to a
byte-at-a-time loop on non-ASCII text: 1.65 GB/s, or 111 ns for a
184-byte row. Measured end to end on wide multibyte strings, importing
through a JVM Parquet scan was 2.1x slower and through native
columnar-to-row 3.3x slower, against 6-11% for ASCII.

simdutf8 validates the same buffers at 11.7 GB/s (15.7 ns/row), 7x
faster on multibyte data and 3x on ASCII. It is already in the
dependency graph through arrow-json, so this adds no new third-party
crate. The `basic` validator is enough here because an invalid buffer
falls through to the element-by-element decoder below, which finds the
bad bytes itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mypwig1QwDcPvDMadVB1d
@manuzhang
manuzhang force-pushed the codex/ffi-utf8-decode branch from fadc7f2 to 62aafe7 Compare September 17, 2026 15:28

@andygrove andygrove left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All three points from my last review are addressed, and I checked the soundness-critical part rather than taking it on trust.

Validating values[start..end] and then rejecting any interior offset that lands on a continuation byte is sufficient. In a valid buffer a non-continuation position is a codepoint boundary, and splitting valid UTF-8 at codepoint boundaries leaves every element valid. offsets[0] and offsets[len] do not need their own check, because a leading or trailing split would already fail the range validation. The o < values.len() guard can only cost a trip through the slow path on a sliced array, never a missed split.

Listing all four from_ffi sites and recording why batch_from_ffi is exempt is what I was after. Making data_type_contains_string exhaustive and returning NotYetImplemented for the view types turns the comment into a mechanism, which was the point.

One thing is still open, and it is the one you flagged yourself. The end-to-end table is the std::str::from_utf8 build and the simdutf8 figures are a projection from the validator microbenchmark. That is honest and I do not think it should hold the fix, since this closes undefined behaviour in the default configuration. Could you re-run CometStringFfiImportBenchmark on the EC2 micro-benchmark runner after this lands and update the table, so the numbers users see are measured rather than extrapolated?

Approving.

@manuzhang
manuzhang added this pull request to the merge queue Sep 18, 2026
Merged via the queue into apache:main with commit 6bef240 Sep 18, 2026
37 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:ffi Arrow FFI / JNI boundary area:scan Parquet scan / data reading area:udf bug Something isn't working correctness

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants